Malware Scanning Implementation Guide
Priority: HIGH Effort: 2-4 hours Impact: Prevents malware upload attacks on all WordPress sites
Problem
The codebase scanner found 126 instances of file upload handling without malware scanning across WordPress plugins and custom code.
Risk: - Users can upload malware-infected files - Files are moved to permanent storage without scanning - Malware can then be executed or distributed to other users
Affected: - WordPress core file uploads - Plugin file uploads (Events Calendar, WooCommerce, etc.) - Custom upload handlers
Solution
Implement ClamAV malware scanning at the WordPress level using a must-use plugin (mu-plugin).
This approach: - β Scans ALL file uploads (core + plugins) - β Runs before files reach permanent storage - β Blocks infected files automatically - β Requires no changes to existing plugins - β Works across all WordPress sites on the server
Prerequisites
1. Install ClamAV
# Install ClamAV
sudo apt-get update
sudo apt-get install clamav clamav-daemon
# Start ClamAV daemon
sudo systemctl start clamav-daemon
sudo systemctl enable clamav-daemon
# Update virus definitions
sudo freshclam
# Verify installation
clamscan --version
2. Test ClamAV
# Download EICAR test virus (safe test file)
curl -o /tmp/eicar.com.txt https://secure.eicar.org/eicar.com.txt
# Scan it (should detect EICAR-Test-File)
clamscan /tmp/eicar.com.txt
# Clean up
rm /tmp/eicar.com.txt
Expected output:
/tmp/eicar.com.txt: Eicar-Test-Signature FOUND
Implementation
Option 1: WordPress mu-plugin (Recommended)
File: /var/www/html/wordpress/wp-content/mu-plugins/clamav-upload-scanner.php
<?php
/**
* Plugin Name: ClamAV Upload Scanner
* Description: Scans all file uploads for malware using ClamAV
* Version: 1.0.0
* Author: Quig Enterprises
*/
namespace CxQ\Security\MalwareScanner;
class ClamAV_Upload_Scanner {
/**
* @var string Path to clamdscan binary
*/
private $clamdscan_path = '/usr/bin/clamdscan';
/**
* @var bool Whether ClamAV is available
*/
private $clamav_available = false;
/**
* Initialize scanner
*/
public function __construct() {
// Check if ClamAV is installed and daemon is running
$this->clamav_available = $this->check_clamav_availability();
if (!$this->clamav_available) {
error_log('[ClamAV Upload Scanner] ClamAV not available - malware scanning disabled');
return;
}
// Hook into file upload process
add_filter('wp_handle_upload_prefilter', [$this, 'scan_upload'], 10, 1);
add_filter('wp_check_filetype_and_ext', [$this, 'scan_upload_alt'], 10, 5);
// Log when scanner is active
error_log('[ClamAV Upload Scanner] Malware scanning active');
}
/**
* Check if ClamAV is available
*
* @return bool
*/
private function check_clamav_availability() {
// Check if binary exists
if (!file_exists($this->clamdscan_path) || !is_executable($this->clamdscan_path)) {
return false;
}
// Test connection to daemon
$test_output = [];
$test_return = 0;
exec($this->clamdscan_path . ' --version 2>&1', $test_output, $test_return);
return $test_return === 0;
}
/**
* Scan uploaded file for malware
*
* @param array $file File upload array
* @return array Modified file array (with error if malware found)
*/
public function scan_upload($file) {
// Skip if ClamAV not available
if (!$this->clamav_available) {
return $file;
}
// Skip if file upload already has error
if (!empty($file['error'])) {
return $file;
}
// Get temporary file path
$tmp_file = isset($file['tmp_name']) ? $file['tmp_name'] : null;
if (!$tmp_file || !file_exists($tmp_file)) {
return $file;
}
// Scan file with ClamAV
$scan_result = $this->scan_file($tmp_file);
if ($scan_result['infected']) {
// Malware detected - block upload
$file['error'] = sprintf(
'Security scan failed: %s. File upload blocked for security reasons.',
esc_html($scan_result['virus'])
);
// Log the blocked upload
error_log(sprintf(
'[ClamAV Upload Scanner] BLOCKED MALWARE UPLOAD: %s (detected: %s) from user %d',
$file['name'] ?? 'unknown',
$scan_result['virus'],
get_current_user_id()
));
// Delete the temporary file
@unlink($tmp_file);
// Optionally trigger security alert
do_action('cxq_malware_detected', [
'file' => $file,
'scan_result' => $scan_result,
'user_id' => get_current_user_id(),
'ip' => $_SERVER['REMOTE_ADDR'] ?? 'unknown'
]);
} else {
// File is clean - allow upload
error_log(sprintf(
'[ClamAV Upload Scanner] CLEAN: %s (scanned by ClamAV)',
$file['name'] ?? 'unknown'
));
}
return $file;
}
/**
* Alternative hook for some upload methods
*
* @param array $wp_check_filetype_and_ext
* @param string $file
* @param string $filename
* @param array $mimes
* @param string $real_mime
* @return array
*/
public function scan_upload_alt($wp_check_filetype_and_ext, $file, $filename, $mimes, $real_mime) {
if (!$this->clamav_available) {
return $wp_check_filetype_and_ext;
}
if (file_exists($file)) {
$scan_result = $this->scan_file($file);
if ($scan_result['infected']) {
$wp_check_filetype_and_ext['proper_filename'] = false;
error_log(sprintf(
'[ClamAV Upload Scanner] BLOCKED (alt hook): %s (detected: %s)',
$filename,
$scan_result['virus']
));
}
}
return $wp_check_filetype_and_ext;
}
/**
* Scan a file with ClamAV
*
* @param string $file_path Path to file to scan
* @return array ['infected' => bool, 'virus' => string|null, 'output' => string]
*/
private function scan_file($file_path) {
$result = [
'infected' => false,
'virus' => null,
'output' => ''
];
// Build command (use clamdscan for faster scanning via daemon)
$command = sprintf(
'%s --no-summary %s 2>&1',
escapeshellcmd($this->clamdscan_path),
escapeshellarg($file_path)
);
// Execute scan
$output = [];
$return_code = 0;
exec($command, $output, $return_code);
$result['output'] = implode("\n", $output);
// Parse result
// Return codes: 0 = clean, 1 = infected, 2 = error
if ($return_code === 1) {
// Infected - extract virus name
$result['infected'] = true;
foreach ($output as $line) {
if (strpos($line, 'FOUND') !== false) {
// Extract virus name from line like: "/path/file: Virus.Name FOUND"
$parts = explode(':', $line);
if (isset($parts[1])) {
$virus_part = trim(str_replace('FOUND', '', $parts[1]));
$result['virus'] = $virus_part;
break;
}
}
}
if (!$result['virus']) {
$result['virus'] = 'Unknown malware';
}
} elseif ($return_code === 2) {
// Error during scan - log but allow upload (fail open)
error_log(sprintf(
'[ClamAV Upload Scanner] SCAN ERROR for %s: %s',
$file_path,
$result['output']
));
}
// return_code === 0 means clean
return $result;
}
}
// Initialize scanner
new ClamAV_Upload_Scanner();
Option 2: Standalone Scanner Class (For Custom Code)
If you need to scan files in custom code (not WordPress uploads):
<?php
namespace CxQ\Security;
class MalwareScanner {
public static function scan_file($file_path) {
$clamdscan = '/usr/bin/clamdscan';
if (!file_exists($clamdscan)) {
throw new \Exception('ClamAV not installed');
}
$command = sprintf(
'%s --no-summary %s 2>&1',
escapeshellcmd($clamdscan),
escapeshellarg($file_path)
);
exec($command, $output, $return_code);
return [
'clean' => $return_code === 0,
'infected' => $return_code === 1,
'error' => $return_code === 2,
'output' => implode("\n", $output)
];
}
}
// Usage:
$result = MalwareScanner::scan_file('/tmp/uploaded_file.pdf');
if ($result['infected']) {
die('Malware detected!');
}
Deployment
Step 1: Deploy to Staging
# Copy mu-plugin to WordPress installation
sudo cp clamav-upload-scanner.php /var/www/html/wordpress/wp-content/mu-plugins/
# Set correct permissions
sudo chown www-data:www-data /var/www/html/wordpress/wp-content/mu-plugins/clamav-upload-scanner.php
sudo chmod 644 /var/www/html/wordpress/wp-content/mu-plugins/clamav-upload-scanner.php
Step 2: Test on Staging
- Test clean file upload:
- Upload a legitimate PDF or image
-
Should succeed with log entry: "CLEAN: filename.pdf (scanned by ClamAV)"
-
Test malware detection: ```bash # Create EICAR test file echo 'X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*' > /tmp/eicar.txt
# Try to upload via WordPress (should be blocked) ``` - Upload should fail with: "Security scan failed: Eicar-Test-Signature. File upload blocked." - Log entry should show: "BLOCKED MALWARE UPLOAD: eicar.txt (detected: Eicar-Test-Signature)"
- Check error logs:
bash sudo tail -f /var/log/nginx/error.log | grep "ClamAV Upload Scanner"
Step 3: Deploy to Production
Once tested on staging:
# Use existing deployment script if available, or:
for site in sandbox.quigs.com board.nwlakes.org; do
echo "Deploying to $site..."
sudo cp clamav-upload-scanner.php /var/www/html/$site/wp-content/mu-plugins/
sudo chown www-data:www-data /var/www/html/$site/wp-content/mu-plugins/clamav-upload-scanner.php
done
Monitoring
Check Scanner Status
# Check if ClamAV daemon is running
sudo systemctl status clamav-daemon
# Check recent scans in WordPress logs
sudo grep "ClamAV Upload Scanner" /var/log/nginx/error.log | tail -20
Performance Monitoring
# Monitor ClamAV performance
sudo clamdscan --version
# Check virus database freshness
sudo systemctl status clamav-freshclam
Alert on Malware Detection
Add to WordPress functions.php or mu-plugin:
add_action('cxq_malware_detected', function($data) {
// Send email alert
wp_mail(
'security@quigs.com',
'SECURITY ALERT: Malware Upload Blocked',
sprintf(
"Malware detected: %s\nUser: %d\nIP: %s\nVirus: %s",
$data['file']['name'],
$data['user_id'],
$data['ip'],
$data['scan_result']['virus']
)
);
// Log to security monitoring system
error_log('[SECURITY] Malware upload blocked: ' . json_encode($data));
});
Performance Considerations
ClamAV Daemon (clamdscan) vs CLI (clamscan)
- clamdscan: Faster (uses daemon, ~100-500ms per file)
- clamscan: Slower (loads definitions each time, ~2-5 seconds per file)
Recommendation: Use clamdscan (daemon) for production
File Size Limits
# Check ClamAV limits
grep -E '(MaxFileSize|MaxScanSize|StreamMaxLength)' /etc/clamav/clamd.conf
# Increase if needed (edit /etc/clamav/clamd.conf):
MaxFileSize 100M
MaxScanSize 100M
StreamMaxLength 100M
# Restart daemon after changes
sudo systemctl restart clamav-daemon
Memory Usage
ClamAV daemon uses ~500MB-1GB RAM. Ensure server has sufficient memory.
Maintenance
Update Virus Definitions
ClamAV automatically updates via clamav-freshclam service:
# Check update status
sudo systemctl status clamav-freshclam
# Manual update
sudo freshclam
# Check database version
sudo clamdscan --version
Log Rotation
Add to /etc/logrotate.d/wordpress-clamav:
/var/log/wordpress-clamav/*.log {
daily
missingok
rotate 14
compress
delaycompress
notifempty
create 0640 www-data www-data
sharedscripts
}
Troubleshooting
ClamAV Daemon Not Running
# Check status
sudo systemctl status clamav-daemon
# View logs
sudo journalctl -u clamav-daemon -n 50
# Restart
sudo systemctl restart clamav-daemon
Scan Timeout
If large files cause timeouts:
# Increase timeout in /etc/clamav/clamd.conf
ReadTimeout 300
# Restart daemon
sudo systemctl restart clamav-daemon
False Positives
If ClamAV blocks legitimate files:
- Verify file is actually clean
- Submit false positive to ClamAV: https://www.clamav.net/reports/fp
- Temporarily whitelist specific files (use with caution):
// In mu-plugin, add before scanning:
$whitelist_hashes = [
'abc123...' => 'Known safe file (verified 2026-03-07)'
];
$file_hash = hash_file('sha256', $tmp_file);
if (isset($whitelist_hashes[$file_hash])) {
return $file; // Skip scan for whitelisted files
}
Success Criteria
β ClamAV daemon running on server β Mu-plugin active on all WordPress sites β Test malware file blocked β Legitimate files upload successfully β Scan results logged to error log β Alerts configured for malware detection β Virus definitions updated daily
Additional Resources
- ClamAV Documentation: https://docs.clamav.net/
- WordPress Upload Filters: https://developer.wordpress.org/reference/hooks/wp_handle_upload_prefilter/
- EICAR Test File: https://www.eicar.org/download-anti-malware-testfile/
Created: 2026-03-07 Author: Blue Team Status: Ready for implementation Estimated Effort: 2-4 hours Impact: HIGH - Protects all WordPress sites from malware uploads